10x 查询性能提升,全新 Unique Key 的设计与实现|新特性解读
# 原 Unique Key 模型的实现
example_tbl
进行desc
,最后一列的聚合类型显示它等价于所有列都是 REPLACE 聚合类型的 Aggregate Key 表。用户在创建 Aggregate Key 模型的表时,对于聚合查询的条件是非常明确的——按照 Aggregate Key 指定的列进行聚合,Value 列上的聚合函数就是用户主要使用的聚合方法(COUNT/SUM/MAX/MIN 等),例如将 user_id
作为 Aggregate Key,将访问次数和时长进行 SUM 聚合来计算 UV 和用户使用时长。但 Unique Key 数据模型的 Key 的主要作用其实是保证唯一性,而不是作为聚合的 Key。例如在订单场景中,通过 Flink CDC 从 TP 数据库同步过来的数据,是以订单 ID 作为 Unique Key 来进行去重的,但查询的时候通常是对某些 Value 列(例如订单状态,订单金额,订单耗时,下单时间等)进行过滤、聚合和分析。
不足之处
数据去重需要执行代价很高的多路归并排序,全量 Key 的比较非常消耗 CPU 的计算资源。 无法进行有效的数据裁剪,引入大量额外的数据 IO。例如某个数据分区有 1 千万条数据,但是符合筛选条件的数据只有 1000 条, OLAP 系统丰富的索引就是用来高效地筛选这 1000 条数据的。但由于无法确定具体文件里某条数据的值是否有效,使得这些索引派不上用场,必须先全部进行一次归并排序并对数据进行去重之后,才能对这些最终确认有效的数据进行过滤。这就带来了约 1 万倍的 IO 放大(这一数字仅为粗略的估计,实际的放大效应计算起来更为复杂)。
# 方案调研与选型
Delete + Insert。即在数据写入时通过一个主键索引查找到被覆盖的 Key,将其标记为删除。比较有代表性的系统是微软的 SQL Server。 Delta Store。将数据分为 Base Data 和 Delta Data,Base Data 中的每一个主键都保证唯一,所有更新都记录在 Delta Store 中,查询时将 Base Data 和 Delta Data 进行 Merge ,同时有后台的 Merge 线程定期将 Delta Data 和 Base Data 进行合并。比较有代表性的系统是 Apache Kudu。 Copy-On-Write。在更新数据时直接将原来的数据整行拷贝、更新后写入新文件。数据湖多采用这类方式,比较有代表性的系统是 Apache Hudi 与 Delta Lake。
Delete + Insert ( 即 Merge-On-Write)
Delta Store
Copy-On-Write
下面的表格对比了各个方案,其中 Merge-On-Read 是 Unique Key 模型的默认实现,即1.2版本之前的实现方式,Merge-On-Write(写时合并)即前面提到的 Delete + Insert 方案。
如上可知,Merge-On-Write 付出中等的写入代价,换取了较低的读取成本,对谓词下推、非 key 列索引过滤均能友好支持,并对查询性能优化有较好的效果。综合对比后,我们选择了 Merge-On-Write 作为最终的优化方案。
# 新版方案的设计与实现
对于每一条 Key,查找它在 Base 数据中的位置(rowsetid + segmentid + 行号) 如果 Key 存在,则将该行数据标记删除。标记删除的信息记录在 Delete Bitmap中,其中每个 Segment 都有一个对应的 Delete Bitmap 将更新的数据写入新的 Rowset 中,完成事务,让新数据可见(能够被查询到) 查询时,读取 Delete Bitmap,将被标记删除的行过滤掉,只返回有效的数据
设计适合 Doris 的 Merge-On-Write 方案,需要重点解决以下几个问题:
导入时如何高效定位到是否存在需要被标记删除的旧数据? 标记删除的信息如何进行高效的存储? 查询阶段如何高效的使用标记删除的信息来过滤数据? 能否实现多版本支持? 如何避免并发导入的事务冲突,导入与 Compaction 的写冲突? 方案引入的额外内存消耗是否合理? 写入代价导致的写入性能下降是否在可接受范围内?
为每个 Segment 维护一个主键索引。主键索引的实现采用了似于 RocksDB Partitioned Index 的方案。该方案能够实现非常高的查询 QPS,同时基于文件的索引方案也能够节省内存占用。 为每个 Segment 维护一个主键索引对应的 Bloom Filter。当 Bloom Filter 命中时才会查询主键索引。 为每个 Segment 记录一个主键的区间范围 [min-key, max-key] 维护一个纯内存的区间树,使用所有 Segment 的主键区间构造。在查询一个主键时,无需遍历所有的 Segment,可以通过区间树定位到可能包含该主键的 Segment,大幅减少需要查询的索引量。 对于命中的所有 Segment,按照版本从高到低进行查询。在 Doris 中高版本意味着更新的数据,因此如果一个主键在高版本的 Segment 索引中命中,就无需继续查询更低版本的 Segment 。
图中的 Segment 文件是由版本 5 的导入产生的,包含了该 Tablet 中版本 5 的导入数据 版本 6 的导入中包含了对主键 B 的更新,因此会在 Bitmap 中将第二行标记删除,并在 DeleteBitmap 中记录版本 6 的导入对该 Segment 的修改 版本 7 的导入包含了对主键 A 的更新,也会产生一个对应版本的 Bitmap;同理版本 8 的导入也会产生一个对应的 Bitmap
using SegmentId = uint32_t;
using Version = uint64_t;
using BitmapKey = std::tuple<RowsetId, SegmentId, Version>;
std::map<BitmapKey, roaring::Roaring> delete_bitmap;
能很好的支持多版本的查询,例如版本 7 的导入完成后,一个该表的查询开始执行,会使用 Version 7 来执行,即使这个查询执行时间较长,在查询执行期间版本 8 的导入已经完成,也无需担心读到版本 8 的数据(或者漏掉被版本 8 删除的数据) 能够很好的支持复杂的 Schema Change。在 Doris 中,复杂的 Schema Change(例如类型转换)需要先进行双写,同时将某个版本之前的历史数据进行转换后再删除掉旧版的数据。多版本的 Delete Bitmap 可以很好的支持当前的 Schema Change 实现 可以支持数据拷贝和副本修复时的多版本需求
写入数据时会先创建每个 Segment 的主键索引,再更新 Delete Bitmap。主键索引的建立比较简单,篇幅有限不再详细描述,重点介绍一些比较复杂的 Delete Bitmap 更新流程:
DeltaWriter 会先将数据 Flush 到磁盘 在 Publish 阶段去批量的点查所有的 Key,并且更新被覆盖的 Key 对应的 Bitmap。在下图中,新写入的 Rowset 版本是 8,它修改了 3 个 Rowset 中的数据,因此会产生 3 个 Bitmap 的修改记录 在 Publish 阶段去更新 Bitmap,保证了批量点查 Key 和更新 Bitmap 期间不会有新增可见的Rowset,保证了 Bitmap 更新的正确性。 如果某个 Segment 没有被修改,则不会有对应版本的 Bitmap 记录,比如 Rowset1 的 Segment1 就没有 Version 8 对应的 Bitmap
一个请求了版本 7 的 Query,只会看到版本 7 对应的数据 读取 Rowset5 的数据时,会将 v6 和 v7 对它的修改产生的 Bitmap 合并在一起,得到 Version7 对应的完整 DeleteBitmap,用来过滤数据 在下图的示例中,版本 8 的导入覆盖了 Rowset1 的 Segment2 一条数据,但请求版本 7 的 Query 仍然能读到该条数据
Compaction 在读取数据时,获取当前处理的 Rowset 的版本 Vx,会自动通过 Delete Bitmap 过滤掉被标记删除的行(见前面的查询层适配部分) Compaction 结束后即可清理源 Rowset 上所有小于等于版本 Vx 的 DeleteBitmap
在 Compaction 的执行过程中,可能会有新的导入任务提交,假设对应版本为 Vy。如果 Vy 对应的写入有对 Compaction 源 Rowset 中的修改,则会更新到该 Rowset 的 DeleteBitmap 的Vy中 在 Compaction 结束后,检查该 Rowset 上所有大于 Vx 的 DeleteBitmap,将它们中的行号更新为新生成的 Rowset 中的 Segment 行号
在最初设计时,DeltaWriter 不在写入数据阶段进行点查和 Delete Bitmap 更新,而是在 Publish 阶段做,这样能保证 Delete Bitmap 更新时可以看到该版本之前所有的数据,保证 Delete Bitmap 的正确性。但是在实际的高频导入测试中发现,Publish 阶段串行对每个 Rowset 的数据进行全量点查和更新时,所引入的额外消耗会使得导入吞吐出现较大幅度下降。
因此在最终设计中,我们将 Delete Bitmap 的更新改成两阶段的形式:第一阶段可以并行执行,只查找和标记删除当时能看到的 Version;第二阶段必须串行执行,将此前第一阶段可能漏查的新导入的 Rowset 中的数据再更新一遍。第二阶段增量更新数据量很少,因此对整体的吞吐影响非常有限。
# 优化效果
新的 Merge-On-Write 实现通过在写入的时候将旧的数据标记删除的方式,能够始终保证有效的主键只会出现在一个文件中(即在写入的时候保证了主键的唯一性),不需要在读取的时候通过归并排序来对主键进行去重,这对于高频写入的场景来说,大大减少了查询执行时的额外消耗。
count(*)
的查询,效果对比如下:在 32C64GB 的典型配置下,所有查询跑完的总耗时,在新版实现上是 4.5 秒,旧版实现为 126.4 秒,速度相差近 30 倍。进一步分析,我们发现在旧版实现的表上的查询执行时,32 核 CPU 全部打满。因此更换了配置更高的机型来测试计算资源充足时旧版实现的表上的查询耗时 在 64C128GB 的配置下,旧版实现总耗时 49.9s,最高约跑满了48 个核。在计算资源充足的情况下,旧版实现仍然与新版实现有 12 倍的性能差距
对数据导入的影响
# 使用方式
“enable_unique_key_merge_on_write” = “true”
# 未来的规划
部分列更新。目前 Unique Key主要支持的是 Upsert 操作(也就是整行更新),暂时不支持 Update操作(也就是每次只更新一部分列的数据)。但在订单、画像等场景中,存在大量的需求,需要频繁且实时的更新一部分状态列,当前只能通过 Aggregate Key 模型的 REPLACE_IF_NOT_NULL
功能来实现部分列更新。未来在 Doris 1.3 版本中,我们计划支持完整的 Unique Key 模型的部分列更新。提高点查性能。Doris 采用的是列式存储,数据按列组织,列存系统天然在大规模数据分析中具有优势,在按行点查上存在劣势(因为一次读取一行需要进行 N 次磁盘文件的访问,相比行存系统来说存在非常大的 IO 放大问题)。随着 Doris 在订单、画像等对实时数据更新有强需求的领域的广泛使用,越来越多的用户提出了希望 Doris 支持高性能点查,以替代 HBase 等系统,来简化运维。这方面我们已经有了初步方案,正在开发中,预计在 2023 年上半年的新版本中可以发布。
目前 Apache Doris 1.2 版本已经发布,欢迎大家下载使用!新版本特性全面了解:全面进化!Apache Doris 1.2.0 Release 版本正式发布|版本通告,也欢迎有兴趣的开发者可以一起参与到社区的开发中。
— END —
最后,欢迎更多的开源技术爱好者加入 Apache Doris 社区,携手成长,共建社区生态。Apache Doris 社区当前已容纳了上万名开发者和使用者,承载了 30+ 交流社群,如果你也是 Apache Doris 的爱好者,扫码加入 Apache Doris 社区用户交流群,在这里你可以获得:
专业全职团队技术支持 直接和社区专家交流,获取免费且专业回复 认识不同行业的开发者,收获知识以及合作机会 Apache Doris 最新版本优先体验权 获取一手干货和资讯以及活动优先参与权
▶ Doris Summit 2022 正式启航,演讲议题开启征集
▶ DDL 毫秒级同步,Light Schema Change 的设计与实现